查看原文
其他

虚拟列表:你有勇气给我 10 万,我就有本事展示给你看

前端大全
2024-08-23

推荐关注↓

今年随着Vue3的成为正式版本,我们的Element-plus也有了稳定版,那今天我们主要是讲一个功能。我们先来看一下Element-plus新出现的一个玩意:


虚拟列表选择器?这是啥玩意,还能虚拟?

大家都知道Vue的虚拟dom,我用简单的话讲述一下:大概就是一棵以 JavaScript 对象作为基础的树,每一个节点称为 VNode ,用对象属性来描述节点,实际上它是一层对真实 DOM 的抽象,最终可以通过渲染操作使这棵树映射到真实环境上。啥事抽象,我也不懂,就跟你问我啥是面向对象一样。

让我们来看一张皇帝选妃图:

这么多的妃子,皇帝肯定一下子不可能全看完,总的一排一排来。再次大殿上装下三千嫔妃,是不是很拥堵。

这就好比我们的页面,后端接口一下给了你10万条数据,如果你一次性渲染到DOM上,会出现很严重的卡顿问题,简称为页面阻塞,这里就暂不讲解url从输入到浏览器渲染的问题,留着下次跟大家分享。

那我们应该怎么解决这种既有10万数据,又可以渲染时不卡顿的现象?

这时候虚拟列表跑出来了,它说:哎,我可以,快用我,快用我,快用我,重要的事情说三遍。

那行呗,那咱就用他,本次呢,我用Vue2element Ui的提供的select组件来实现 Vue2版select虚拟列表选择器

思路

利用滚动事件,去计算可视窗口内的第一项和最后一项,利用总数据数组分割的方法slice截取到可视列表数据,计算每条数据的height(高度)和offset(距离顶部的位置)缓存到一个数组上来保存信息,利用每条高度求出总数据的应该有的高度listTotalHeight,最后利用相对定位和绝对定位的结合,使用transform控制translateY使可视列表位置保持在可视窗口,如下图:

实现

首先我们来实现一个纯页面输出的虚拟列表,根据上面的思路实现一下,基本组件select-v2.vue

<template>
    <div
        ref="wrapper"
        @scroll="refreshView()"
        style="width: 100%; height: 100%; overflow: auto; position: relative; margin: 0; padding: 0; border: none;"
    >
        <div :style="{ height: listTotalHeight + 'px' }" style="width: 100%; padding: 0; margin: 0;"></div>
        <div
            ref="item-wrapper"
            style="position: absolute; top: 0; left: 0; width: 100%; padding: 0; margin: 0;"
        >
            <div v-for="(d) in listViewWithInfo" :key="d.index" :style="{ height: d.height + 'px' }">
                <slot :item="d.item" :height="d.height" :offset="d.offset" :index="d.index"></slot>
            </div>
        </div>
    </div>

</template>
<script>
export default {
    props: {
        list: Array, // 列表数据
        itemHeightGetter: Function, // 获取列表高度的函数
        defaultItemHeight: Number, // 默认item高度
    },
    data() {
        return {
            listView: [], // 可视列表数据
            listTotalHeight: 0, // 列表总高度
            itemOffsetCache: [], // item信息缓存
            topItemIndex: 0, // 可视窗口的第一项
        };
    },
    computed: {
        listViewWithInfo() { // 封装listView,提供index、height、offset数据
            return this.listView.map((item, viewIndex) => {
                const index = this.topItemIndex + viewIndex;
                const { height, offset } = this.getItemInfo(index);
                return {
                    index,
                    item,
                    height,
                    offset,
                }
            });
        }
    },
    watch: {
        list() {
            this.refreshView();
        },
    },
    mounted() {
        this.refreshView({ resize: true });
    },
    methods: {
        // 重渲染可视列表(可供组件外部调用)
        refreshView(config) {
            if (config) {
                if (config.resize) { // 只有resize为true时对wrapper高度重新取值,减少DOM取值操作
                    this._viewHeight = this.$refs.wrapper.clientHeight;
                }
                if (config.clearCache) { // 清空缓存
                    this.itemOffsetCache = [];
                }
            }
            const scrollTop = this.$refs.wrapper.scrollTop; // 当前scrollTop
            const viewHeight = this._viewHeight; // 可视窗口高度
            const topItemIndex = this.findItemIndexByOffset(scrollTop); // 可视窗口的第一项
            const bottomItemIndex = this.findItemIndexByOffset(scrollTop + viewHeight); // 可视窗口的最后项
            this.topItemIndex = topItemIndex;
            this.listView = this.list.slice(topItemIndex, bottomItemIndex + 1); // 可视列表

            // 列表总高度
            // 若提供了默认item高度(defaultItemHeight),则高度 = 已计算item的高度总合 + 未计算item数 * 默认item高度;否则全部使用计算高度
            // 这里已计算过的item会缓存,所有item只会计算一次
            const listTotalHeight = this.defaultItemHeight
                ? this.getItemInfo(this.itemOffsetCache.length - 1).offset + (this.list.length - this.itemOffsetCache.length) * this.defaultItemHeight
                : this.getItemInfo(this.list.length - 1).offset;

            this.listTotalHeight = listTotalHeight;
            console.log(listTotalHeight)

            this.$refs['item-wrapper'].style.transform = `translateY(${this.getItemInfo(topItemIndex - 1).offset}px)`; // 控制translateY使可视列表位置保持在可视窗口

            // 对外抛出scroll事件
            this.$emit('scroll', {
                topItemIndex,
                bottomItemIndex,
                listTotalHeight,
                scrollTop
            });
        },
        // 根据offset获取item的在列表中的index
        findItemIndexByOffset(offset) {
            // 如果offset大于缓存数组的最后项,按序依次往后查找(调用getItemInfo的过程也会缓存数组)
            if (offset >= this.getItemInfo(this.itemOffsetCache.length - 1).offset) {
                for (let index = this.itemOffsetCache.length; index < this.list.length; index++) {
                    if (this.getItemInfo(index).offset > offset) {
                        return index;
                    }
                }
                return this.list.length - 1;
            } else { // 如果offset小于缓存数组的最后项,那么在缓存数组中二分法查找
                let begin = 0;
                let end = this.itemOffsetCache.length - 1;
                while (begin < end) {
                    let mid = (begin + end) /
 2 | 0;
                    let midOffset = this.getItemInfo(mid).offset;
                    if (midOffset === offset) {
                        return mid;
                    } else if (midOffset > offset) {
                        end = mid - 1;
                    } else {
                        begin = mid + 1;
                    }
                }
                if (this.getItemInfo(begin).offset < offset && this.getItemInfo(begin + 1).offset > offset) {
                    begin = begin + 1;
                }
                return begin;
            }
        },
        // 获取item信息(有缓存则取缓存,无缓存则计算并缓存)
        getItemInfo(index) {
            // 超出取值范围,返回默认值
            if (index < 0 || index > this.list.length - 1) {
                return {
                    offset0,
                    height0,
                };
            }
            let cache = this.itemOffsetCache[index];
            // 如果没有缓存,进行计算并缓存结果
            if (!cache) {
                // 优先用itemHeightGetter计算高度,无itemHeightGetter则取defaultItemHeight作为高度
                let height = (this.itemHeightGetter ? this.itemHeightGetter(this.list[index], index) : this.defaultItemHeight);
                cache = this.itemOffsetCache[index] = {
                    height, // item高度
                    offsetthis.getItemInfo(index - 1).offset + height, // 递归得出item的bottom距离列表顶部的距离,item的offset = 上个item的offset + 自己的height
                };
            }
            // 如果已有缓存,直接返回缓存的结果
            return cache;
        },
    },
}
</script>

使用select-v2.vue组件:

<template>
  <div id="app">
    <div class="m-container">
      <div class="m-header">我是头部</div>
      <div class="m-list">
        <div class="m-list-container">
          <select-v2
            ref="list-view"
            @scroll="listScroll"
            :list="list"
            :item-height-getter="itemHeightGetter"
            :default-item-height="defaultItemHeight"
          >
            <div slot-scope="scope" class="item">
              <div
                :style="{ color: scope.item.color }"
              >NO: {{ scope.item.no }}, height: {{ scope.height }}px, offset: {{ scope.offset }}px</div>
            </div>
          </select-v2>
        </div>
      </div>
    </div>
  </div>

</template>

<script>
import selectv2 from './
components/select-v2.vue'

export default {
  name: '
App',
  components: {
    '
select-v2': selectv2,
  },
  data() {
    return {
      list: [],
      page: 0,
      itemHeightGetter(item) {
        if (item.no % 33 === 0) {
          return 100;
        }
        return 20 + item.no % 10;
      },
      defaultItemHeight: 30,
    }
  },
  created() {
    this.getData().then(d => {
      this.list = d;
    });
  },
  methods: {
    listScroll(data) {
      if (!this._getting && data.bottomItemIndex >= this.list.length - 3) {
        this._getting = true;
        this.getData().then(d => {
          this.list.push(...d);
          this.page++;
          this._getting = false;
        });
      }
    },
    getData() {
      return new Promise(resolve => {
        setTimeout(() => {
          const baseIndex = this.page * 2000;
          resolve(new Array(2000).fill(0).map((i, index) => {
            return {
              no: baseIndex + index,
              color: ['
#33d', '#3d3', '#d33', '#333'][Math.random() * 4 | 0],
            };
          }));
        }, 100);
      })
    },
  },

}
</script>

<style lang="scss">
html,
body,
#app {
  margin: 0;
  width: 100vw;
  height: 100vh;
  padding: 0;
}

.m-container {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  .m-header {
    height: 40px;
    background: greenyellow;
  }
  .m-list {
    flex: 1;
    position: relative;
    .m-list-container {
      position: absolute;
      width: 100%;
      height: 100%;
      .item {
        height: 100%;
        display: flex;
        align-items: center;
      }
    }
  }
}
</style>

最后如果你的效果是这样的,那么说明你就成功了一大步了

图片过大,截图处理

代码中是有2000条的数据的,但是我们在渲染的时候刚好满足可视窗口高度的列表,因为代码中为了显示特别一点,还特意加了一个独特的高度的,所以在滚动的时候无法精确到每次都有一定的条数。item-height-getter可以通过这个来添加个别Item特别的高度。

default-item-height这个可以设置item默认的高度,在没有设置item-height-getter情况下,就可以固定每次显示多少条,因为高度都是一样的,可视窗口就那么宽。有兴趣的朋友可以自己去试一下,这里就不做展示了。

但是有伙伴会说我不知道一个item的内容有多少字,可能会有很多的字,但是我又不想单独的设置item-height-getter,譬如我们将App.vue改写成这样:

...
<select-v2
  ref="list-view"
  @scroll="listScroll"
  :list="list"
  :item-height-getter="itemHeightGetter"
  :default-item-height="defaultItemHeight"
>
  <div slot-scope="scope" class="item">
    <div v-if="scope.item.no === 1">代码中是有2000条的数据的,但是我们在渲染的时候刚好满足可视窗口高度的列表,因为代码中为了显示特别一点,还特意加了一个独特的高度的,所以在滚动的时候无法精确到每次都有一定的条数。`item-height-getter`可以通过这个来添加个别`Item`特别的高度。`default-item-height`这个可以设置item默认的高度,在没有设置`item-height-getter`情况下,就可以固定每次显示多少条,因为高度都是一样的,可视窗口就那么宽。有兴趣的朋友可以自己去试一下,这里就不做展示了。</div>
    <div
    v-else
      :style="{ color: scope.item.color }"
    >NO: {{ scope.item.no }}, height: {{ scope.height }}px, offset: {{ scope.offset }}px</div>
  </div>

 ...

页面就变成了这样:


我们可以将select-v2.vue改一个小地方就可以了:

...
<div
    ref="item-wrapper"
    style="position: absolute; top: 0; left: 0; width: 100%; padding: 0; margin: 0;"
>
    <div v-for="(d) in listViewWithInfo" :key="d.index" :style="{ 'min-height': d.height + 'px' }">
        <slot :item="d.item" :height="d.height" :offset="d.offset" :index="d.index"></slot>
    </div>

</div>
...

细心的伙伴可能就已经发现了,就是将item的高度设置时,改为min-height即可,页面就回归正常了,有兴趣了伙伴可以尝试一下:


现在我们已经完成了我们最基本的虚拟列表组件,但是我们最终的目标是:虚拟列表选择器

虚拟列表选择器

那来呗,我们先安装和引入ElementUI:

npm i element-ui -S
//在main.js引入
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
Vue.use(ElementUI);

我们来将select-v2.vue组件改写:

<template>
  <el-select
    @change="handleChange"
    :placeholder="placeholder"
    clearable
    filterable
    remote
    :filter-method="filterMethod"
    :remote-method="remoteMethod"
    :loading="loading"
    v-model="selectValue"
    :popper-class="`m-el-select-v2 ${popperClass ? popperClass : ''}`"
    @visible-change="visibleChange"
  >
    <div
      ref="wrapper"
      class="m-virtual-wrapper"
      @scroll="refreshView()"
      style="
        width: 100%;
        height: 100%;
        overflow: auto;
        position: relative;
        margin: 0;
        padding: 0;
        border: none;
      "
    >
      <div
        :style="{ height: listTotalHeight + 'px' }"
        ref="listTotalHeightRef"
        style="width: 100%; padding: 0; margin: 0"
      ></div>
      <div
        ref="item-wrapper"
        style="position: absolute; top: 0; left: 0; width: 100%; padding: 0; margin: 0"
      >
        <div v-for="d in listViewWithInfo" :key="d.index" :style="{ height: d.height + 'px' }">
          <slot :item="d.item" :height="d.height" :offset="d.offset" :index="d.index">
            <el-option :label="d.item.label" :value="d.item.value"> </el-option>
          </slot>
        </div>
      </div>
    </div>
  </el-select>

</template>

<script>
export default {
  name: "m-select-v2",
  props: {
    list: Array, // 列表数据
    itemHeightGetter: Function, // 获取列表高度的函数
    defaultItemHeight: {
      type: Number,
      default: 45,
    }, // 默认item高度
    placeholder: {
      type: String,
      default: "请选择",
    },
    popperClass: String,
    value: [String, Number],
  },
  data() {
    return {
      listView: [], // 可视列表数据
      listTotalHeight: 0, // 列表总高度
      itemOffsetCache: [], // item信息缓存
      topItemIndex: 0, // 可视窗口的第一项
      loading: false,
      allList: [],
      //   selectValue:""
    };
  },
  computed: {
    selectValue: {
      get() {
        return this.value;
      },
      set(newValue) {
        return newValue;
      },
    },
    listViewWithInfo() {
      // 封装listView,提供index、height、offset数据
      return this.listView.map((item, viewIndex) => {
        const index = this.topItemIndex + viewIndex;
        const { height, offset } = this.getItemInfo(index);
        return {
          index,
          item,
          height,
          offset,
        };
      });
    },
  },
  watch: {
    list() {
      this.allList = this.list;
      //   console.log("传进来的数据列表",this.list)
      this.refreshView();
    },
  },
  mounted() {
    // console.log("我进来了", this.list);
    this.refreshView({ resize: true });
  },
  methods: {
    handleChange(val) {
      this.$emit("input", val);
      this.$emit("change", val);
    },
    visibleChange(status) {
      console.log(status);
      this.allList = this.list;
      this.refreshView({ clearCache: true });
      this.$emit("visible-change", status);
    },
    filterMethod(query) {
      console.log(query);
      this.remoteMethod(query)
    },
    remoteMethod(query) {
      //   console.log("我进来了", query);
      if (query.trim() !== "") {
        this.loading = true;

        setTimeout(() => {
          this.loading = false;
          //   console.log("搜索输的值",this.list)
          // this.$emit('filter',query)
          var list = this.list.filter((item) => {
            return item.label && item.label.indexOf(query) > -1;
          });
          //   console.log("搜索输的值", list);
          if(list.length){
            this.allList = list;

            this.$nextTick(() => {
              this.$refs["item-wrapper"].style.transform = "translateY(0px)";
              this.$refs.wrapper.scrollTop = 0;
              this.refreshView({ clearCache: true, isTranslateY: true });
            });
          }else{
            console.log("没有找到,支持远程搜索,可以自定义事件")
            this.$emit("remote-method",query)
          }
        }, 200);
      } else {
        this.allList = this.list;
        this.refreshView({ clearCache: true });
      }
    },
    // 重渲染可视列表(可供组件外部调用)
    refreshView(config) {
      //   console.log("滚动了吗");
      if (config) {
        if (config.resize) {
          // 只有resize为true时对wrapper高度重新取值,减少DOM取值操作
          this._viewHeight = this.$refs.wrapper.clientHeight;
        }
        if (config.clearCache) {
          // 清空缓存
          this.itemOffsetCache = [];
        }
      }
      //   console.log("当前scrollTop",this.$refs.wrapper.scrollTop)
      const scrollTop = this.$refs.wrapper.scrollTop; // 当前scrollTop
      const viewHeight = this._viewHeight || 274; // 可视窗口高度
      const topItemIndex = this.findItemIndexByOffset(scrollTop); // 可视窗口的第一项
      const bottomItemIndex = this.findItemIndexByOffset(scrollTop + viewHeight); // 可视窗口的最后项
      this.topItemIndex = topItemIndex;
      this.listView = this.allList.slice(topItemIndex, bottomItemIndex + 1); // 可视列表

      // 列表总高度
      // 若提供了默认item高度(defaultItemHeight),则高度 = 已计算item的高度总合 + 未计算item数 * 默认item高度;否则全部使用计算高度
      // 这里已计算过的item会缓存,所有item只会计算一次
      const listTotalHeight = this.defaultItemHeight
        ? this.getItemInfo(this.itemOffsetCache.length - 1).offset +
          (this.allList.length - this.itemOffsetCache.length) * this.defaultItemHeight
        : this.getItemInfo(this.allList.length - 1).offset;

      this.listTotalHeight = listTotalHeight;

      this.$refs["item-wrapper"].style.transform = `translateY(${
        this.getItemInfo(topItemIndex - 1).offset
      }px)`;
      console.log(this.scrollTop, scrollTop, topItemIndex, bottomItemIndex);
      // 对外抛出scroll事件
      this.$emit("scroll", {
        topItemIndex,
        bottomItemIndex,
        listTotalHeight,
        scrollTop,
      });
      this.$forceUpdate();
    },
    // 根据offset获取item的在列表中的index
    findItemIndexByOffset(offset) {
      // 如果offset大于缓存数组的最后项,按序依次往后查找(调用getItemInfo的过程也会缓存数组)
      if (offset >= this.getItemInfo(this.itemOffsetCache.length - 1).offset) {
        for (let index = this.itemOffsetCache.length; index < this.allList.length; index++) {
          if (this.getItemInfo(index).offset > offset) {
            return index;
          }
        }
        return this.allList.length - 1;
      } else {
        // 如果offset小于缓存数组的最后项,那么在缓存数组中二分法查找
        let begin = 0;
        let end = this.itemOffsetCache.length - 1;
        while (begin < end) {
          let mid = ((begin + end) /
 2) | 0;
          let midOffset = this.getItemInfo(mid).offset;
          if (midOffset === offset) {
            return mid;
          } else if (midOffset > offset) {
            end = mid - 1;
          } else {
            begin = mid + 1;
          }
        }
        if (
          this.getItemInfo(begin).offset < offset &&
          this.getItemInfo(begin + 1).offset > offset
        ) {
          begin = begin + 1;
        }
        return begin;
      }
    },
    // 获取item信息(有缓存则取缓存,无缓存则计算并缓存)
    getItemInfo(index) {
      // 超出取值范围,返回默认值
      if (index < 0 || index > this.allList.length - 1) {
        return {
          offset0,
          height0,
        };
      }
      let cache = this.itemOffsetCache[index];
      // 如果没有缓存,进行计算并缓存结果
      if (!cache) {
        // 优先用itemHeightGetter计算高度,无itemHeightGetter则取defaultItemHeight作为高度
        let height = this.itemHeightGetter
          ? this.itemHeightGetter(this.allList[index], index)
          : this.defaultItemHeight;
        cache = this.itemOffsetCache[index] = {
          height, // item高度
          offsetthis.getItemInfo(index - 1).offset + height, // 递归得出item的bottom距离列表顶部的距离,item的offset = 上个item的offset + 自己的height
        };
      }
      // 如果已有缓存,直接返回缓存的结果
      return cache;
    },
  },
};
</script>

<style lang="scss">
.m-el-select-v2 {
  .el-select-dropdown__wrap {
    overflow: hidden;
    margin-bottom: 0px ;
    margin-right: 0px ;
    .el-select-dropdown__list {
      width: 100%;
      height: 274px;
      overflow: hidden;
      .m-virtual-wrapper {
        &::-webkit-scrollbar {
          width: 6px;
        }
        &::-webkit-scrollbar-thumb {
          background-color: #a1a3a9;
          border-radius: 3px;
        }
        &::-webkit-scrollbar-track {
          // background: #f5f7fa;
          background: transparent;
        }
        &::-webkit-scrollbar-corner {
          background: #f5f7fa;
        }
      }
    }
  }
}
</
style>

其中默认添加了elementUI组件提供的属性,其中的功能主要有:清空、筛选(远程搜索的相应事件修改过来的)、插槽(可以修改选择列表中展示的内容)、滚动事件回调可以方便你增加页面去请求数据。功能都就可以根据自己的需要做出增加和修改。

这里我就举一个插槽的效果:

//App.vue
//......其他的就省略不写了,因为都一样
<select-v2 v-model="selectValue" @scroll="listScroll" :list="list" :itemHeightGetter="itemHeightGetter" :default-item-height="defaultItemHeight">
  <template #default="{ item }">
    <el-option :label="item.label" :value="item.value">
      <span>{{ item.label }}({{ item.no }})</span>
    </el-option>
  </template>

</select-v2>
//......其他的就省略不写了,因为都一样

最终效果:

图片过大,截图处理

好了,最终的就这样完成了。但是这个做的过程中我发现一个问题,伙伴们可以自己感受一下,可以自己琢磨一下。

问题:当模糊搜索的时候,我们肯定是要从搜索到的结果的第一项去展示出来的,但是事实并不是这样的

设置的translateY偏移量去展示的已经归零了,但是我们滚动上去的部分就回不来了。这是为什么呢?这个问题有小伙伴们去思考吧。

我的解决方案就是:

//在remoteMethod方法中添加下面两句
//......
this.$refs["item-wrapper"].style.transform = "translateY(0px)";
this.$refs.wrapper.scrollTop = 0;
//......


作者:前端周星星

https://juejin.cn/post/7069681651789332493

- EOF -

推荐阅读  点击标题可跳转

1、产品:“写个 banner 这么费劲?”

2、一个容易忽略但很有用的 TS 类型

3、手写一个虚拟 DOM 库,彻底让你理解 diff 算法


觉得本文对你有帮助?请分享给更多人

推荐关注「前端大全」,提升前端技能

点赞和在看就是最大的支持❤️

继续滑动看下一个
前端大全
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存